Skip to content

Compose VM rootfs from shared layer blobs - #457

Draft
chruffins wants to merge 2 commits into
hypeship/layer-artifactsfrom
hypeship/rootfs-composition
Draft

Compose VM rootfs from shared layer blobs#457
chruffins wants to merge 2 commits into
hypeship/layer-artifactsfrom
hypeship/rootfs-composition

Conversation

@chruffins

@chruffins chruffins commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

summary

Stacked stage of the image-storage project: the production composition path.

  • composeRootfs builds one merged rootfs per image by applying the manifest's ordered layer blobs from the shared OCI cache in tar order. Whiteout and opaque-directory markers are interpreted as each layer is applied; entries replace conflicting types safely; hardlink targets resolve against the accumulated tree. The merged tree is exported to the same single read-only disk the guest mounts as its lower filesystem.
  • validateModelPairing enforces one config diff id per manifest layer, so a corrupt manifest fails the build with a clean error instead of indexing past the pairing (preserves the captured-fixture regression behavior). The umoci unpack remains a fallback when no manifest model exists.
  • extractTarEntry now clears non-empty directories and type-conflicting entries before writing, so a layer can replace a directory with a file (and vice versa).

why this path

The guest init mounts exactly one read-only lower (/dev/vda) and one writable upper (/dev/vdb), and QEMU microvm virtio-mmio slots bound how many devices can be attached. A one-disk-per-layer layout therefore cannot be supported uniformly across cloud-hypervisor, Firecracker, QEMU, and vz, so it was rejected rather than attached blindly. Sharing happens at the content-addressed blob and layer-artifact level; every hypervisor keeps its existing disk contract (vda read-only rootfs, vdb writable overlay), and guest init and hypervisor config are unchanged.

validation

  • Synthetic two-layer fixture with whiteout, opaque mask, file/directory replacement, and mode checks composes to the expected tree; composed output exports to erofs and passes fsck.erofs --extract.
  • Captured corrupt-manifest fixture still fails cleanly with the original integrity error.
  • go test ./lib/images ./lib/paths ./lib/builds ./lib/scopes ./cmd/api/api: green except Docker Hub pull tests (anonymous rate limit in this environment) and VM lifecycle tests, which require network-bridge privileges (verified failing identically on unmodified main).

Note

Medium Risk
Switches the primary rootfs build path for normal images and changes tar extraction semantics shared with layer unpacking; guest disk contract is unchanged and umoci fallback limits blast radius.

Overview
Image export now merges manifest-ordered layer blobs from the shared OCI cache into one staging rootfs (composeRootfs / applyLayerToDir) instead of always using umoci unpack when a manifest model is available. Whiteout and opaque-directory markers are applied as each layer is streamed, so the composed tree is meant for a single read-only guest disk without relying on overlayfs to interpret .wh.* files.

validateModelPairing rejects configs where rootfs.diff_ids length mismatches manifest layer count (same integrity message as the umoci path). If no model or empty layers, umoci unpackLayers remains the fallback.

extractTarEntry now uses clearExisting (including RemoveAll for directories) so later layer entries can replace a path with a different type—e.g. directory → file—fixing composition cases like replacedir in the new tests.

Tests cover whiteout/opaque/replacement ordering, missing blobs, and erofs export + fsck.erofs --extract on the composed tree.

Reviewed by Cursor Bugbot for commit 2b465b6. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2b465b6. Configure here.

Comment thread lib/images/compose.go Outdated
continue
}
clearExisting(filepath.Join(dest, dir, hidden))
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whiteouts escape compose root

High Severity

Opaque and whiteout handling joins header.Name onto dest with filepath.Join and never runs safeJoin, unlike regular entries. A layer whose whiteout path contains .. can make clearDirContents / clearExisting delete files outside the compose tree during image build, including host paths under the build parent.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2b465b6. Configure here.

Comment thread lib/images/compose.go Outdated
continue
}
clearExisting(filepath.Join(dest, dir, hidden))
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty whiteout deletes parent

Medium Severity

A whiteout whose name is exactly .wh. yields an empty hidden target. After skipping only . and .., clearExisting runs on the parent directory and RemoveAlls it, so a single marker can wipe the containing directory instead of hiding one entry.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2b465b6. Configure here.

@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from 2b465b6 to fb40cc1 Compare August 26, 2026 18:44
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch 2 times, most recently from 5675147 to 6bde47c Compare August 26, 2026 18:50
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from 6bde47c to abdceaf Compare August 26, 2026 18:52
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from abdceaf to 0d9f270 Compare August 26, 2026 18:53
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from 0d9f270 to de83eaa Compare August 26, 2026 18:54
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch 2 times, most recently from 2093a74 to 92dd458 Compare August 26, 2026 18:56
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from 92dd458 to 5ec53e4 Compare August 26, 2026 18:58
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from 5ec53e4 to e290067 Compare August 26, 2026 19:26
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from e290067 to c69e90b Compare August 26, 2026 19:30
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch 3 times, most recently from d4801d7 to f24fed3 Compare August 26, 2026 19:41
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch 2 times, most recently from cb42b2d to 3a95ff6 Compare August 26, 2026 19:50
The production composition path builds one merged rootfs per image by
applying the manifest's ordered layer blobs from the shared OCI cache in
tar order: whiteout and opaque-directory markers are interpreted as each
layer is applied, entries replace conflicting types safely, and hardlink
targets resolve against the accumulated tree. The result is exported to
the same single read-only disk the guest already mounts as its lower
filesystem, with the writable overlay upper untouched.

Attaching one disk per layer was rejected: the guest init mounts exactly
one read-only lower plus one writable upper, and QEMU microvm virtio-mmio
slots bound the device count, so a per-layer-disk layout cannot be
supported uniformly across cloud-hypervisor, Firecracker, QEMU, and vz.
Sharing happens at the content-addressed blob and layer-artifact level
instead, and every hypervisor keeps its existing disk contract.

Composition also enforces the config diff-id pairing integrity check, so
corrupt manifests fail the build with a clean error instead of panicking;
the umoci unpack remains as a fallback when no manifest model exists.
@chruffins
chruffins force-pushed the hypeship/rootfs-composition branch from 3a95ff6 to 3f9ef7f Compare August 26, 2026 22:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant